WatchView.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { ChannelDetail, ChannelStatusUpdate } from '@/types/channel';
  4. import { useSignalRContext } from '@/contexts/signalrProvider';
  5. import { buildChannelUrl, formatHandle } from '@/lib/utils/channel';
  6. import YouTubeChatIframe from './YouTubeChatIframe';
  7. import DonationToast from './DonationToast';
  8. import DonationModal from './DonationModal';
  9. import ShareMenu from './ShareMenu';
  10. import FollowButton from '@/app/component/FollowButton';
  11. import useAuth from '@/hooks/useAuth';
  12. import Link from 'next/link';
  13. import './style.scss';
  14. // [DEPRECATED] dpot SignalR 채팅 — YouTube Live Chat iframe 으로 대체.
  15. // quota 승인 후 재활성화 예정. 자세한 작업 목록: memory/plan_dpot_chat_reactivate_after_quota.md
  16. // import ChatSidebar from './ChatSidebar';
  17. type Props = {
  18. channel: ChannelDetail;
  19. };
  20. function formatCount(n: number): string {
  21. if (n >= 10000) {
  22. const v = (n / 10000).toFixed(n >= 100000 ? 0 : 1);
  23. return `${v.replace(/\.0$/, '')}만명`;
  24. }
  25. return `${n.toLocaleString()}명`;
  26. }
  27. export default function WatchView({ channel }: Props)
  28. {
  29. const [showDonation, setShowDonation] = useState(false);
  30. const [descOpen, setDescOpen] = useState(false);
  31. const [isLive, setIsLive] = useState(channel.isLive);
  32. const [videoId, setVideoId] = useState<string|null>(channel.videoId);
  33. const [viewerCount, setViewerCount] = useState(channel.viewerCount);
  34. const [origin, setOrigin] = useState<string|null>(null);
  35. // ChannelStatusBroadcaster 는 AppHub 의 channel:{sid} 그룹으로 송출하므로 appConnection 사용 필수.
  36. // (chatConnection 으로 받으면 그룹 키 미스매치 + ChatHub.JoinChannel 의 입장 메시지 도배 부작용)
  37. const { appConnection } = useSignalRContext();
  38. const { loginCheck } = useAuth();
  39. const handleDonate = () => {
  40. if (!loginCheck()) {
  41. return;
  42. }
  43. setShowDonation(true);
  44. };
  45. // SSR 시점엔 window 가 없으므로 마운트 후 origin 해석
  46. useEffect(() => {
  47. if (typeof window !== 'undefined') {
  48. setOrigin(window.location.origin);
  49. }
  50. }, []);
  51. // SignalR 실시간 채널 상태 업데이트 (AppHub.ReceiveChannelStatus — 라이브 시작/종료, 시청자 수)
  52. useEffect(() => {
  53. if (!appConnection) {
  54. return;
  55. }
  56. const handler = (status: ChannelStatusUpdate) => {
  57. if (status.channelSID !== channel.channelSID) {
  58. return;
  59. }
  60. setIsLive(status.isLive);
  61. setVideoId(status.videoId);
  62. setViewerCount(status.viewerCount);
  63. };
  64. appConnection.on('ReceiveChannelStatus', handler);
  65. return () => {
  66. appConnection.off('ReceiveChannelStatus', handler);
  67. };
  68. }, [appConnection, channel.channelSID]);
  69. // ChannelStatusBroadcaster 가 AppHub 의 Clients.Group("channel:{sid}") 으로 송출 → AppHub.JoinChannel 로 가입.
  70. // AppHub.JoinChannel 은 순수 그룹 가입만 수행 (사이드이펙트 없음).
  71. // 재연결 시에도 자동 재가입.
  72. useEffect(() => {
  73. if (!appConnection) {
  74. return;
  75. }
  76. const sid = channel.channelSID;
  77. const join = async () => {
  78. if (appConnection.state !== 'Connected') {
  79. return;
  80. }
  81. try {
  82. await appConnection.invoke('JoinChannel', sid);
  83. } catch (err) {
  84. console.warn('[WatchView] JoinChannel 실패:', sid, err);
  85. }
  86. };
  87. join();
  88. appConnection.onreconnected(join);
  89. return () => {
  90. if (appConnection.state !== 'Connected') {
  91. return;
  92. }
  93. appConnection.invoke('LeaveChannel', sid).catch(() => {});
  94. };
  95. }, [appConnection, channel.channelSID]);
  96. // YouTube embed URL.
  97. // - youtube-nocookie.com: 임베드 제한이 youtube.com 대비 관대 (privacy-enhanced mode)
  98. // - origin / widget_referrer: 호스트 명시 → "다른 웹사이트에서 재생 차단" 케이스 일부 회피
  99. // - enablejsapi=1: origin 인식에 필요
  100. // - playsinline=1: iOS Safari 인라인 재생
  101. // 채널 소유자가 YouTube Studio에서 "임베드 허용"을 끈 경우는 클라이언트로 해결 불가.
  102. const embedUrl = videoId
  103. ? `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&enablejsapi=1${origin ? `&origin=${encodeURIComponent(origin)}&widget_referrer=${encodeURIComponent(origin)}` : ''}`
  104. : null;
  105. const shareTitle = channel.liveTitle || channel.name;
  106. const showViewers = isLive && viewerCount > 0;
  107. return (
  108. <div className="watch-page">
  109. <div className="watch-page__content">
  110. {/* 플레이어 */}
  111. <div className="watch-page__player">
  112. {embedUrl ? (
  113. <iframe
  114. src={embedUrl}
  115. allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
  116. allowFullScreen
  117. title={channel.liveTitle || channel.name}
  118. />
  119. ) : (
  120. <div className="watch-page__offline">
  121. <p>현재 방송 중이 아닙니다</p>
  122. <Link href={buildChannelUrl(channel)}>채널 페이지로 이동</Link>
  123. </div>
  124. )}
  125. </div>
  126. {/* 방송 정보 */}
  127. <div className="watch-page__info">
  128. <h1 className="watch-page__title">{channel.liveTitle || channel.name}</h1>
  129. <div className="watch-page__meta">
  130. <Link href={buildChannelUrl(channel)} className="watch-page__channel">
  131. {channel.thumbnailUrl ? (
  132. <img src={channel.thumbnailUrl} alt="" className="watch-page__avatar" />
  133. ) : (
  134. <span className="watch-page__avatar watch-page__avatar--default" aria-hidden="true">
  135. {channel.name.charAt(0)}
  136. </span>
  137. )}
  138. <span className="watch-page__channel-body">
  139. <span className="watch-page__channel-name">
  140. {channel.name}
  141. {channel.isVerified && (
  142. <span className="watch-page__verified" title="인증됨" aria-label="인증됨">✓</span>
  143. )}
  144. </span>
  145. <span className="watch-page__channel-sub">
  146. 구독자 {formatCount(channel.subscriberCount)}
  147. {channel.handle && <> · {formatHandle(channel.handle)}</>}
  148. {showViewers && (
  149. <>
  150. {' · '}
  151. <span className="watch-page__viewers" aria-label={`실시간 시청자 ${formatCount(viewerCount)}`}>
  152. <span className="watch-page__viewers-dot" aria-hidden="true" />
  153. 시청자 {formatCount(viewerCount)}
  154. </span>
  155. </>
  156. )}
  157. </span>
  158. </span>
  159. </Link>
  160. <div className="watch-page__actions">
  161. <FollowButton memberSID={channel.memberSID} className="watch-page__follow" />
  162. <ShareMenu title={shareTitle} />
  163. </div>
  164. </div>
  165. {channel.description && (
  166. <div className={`watch-page__desc ${descOpen ? 'watch-page__desc--open' : ''}`}>
  167. <p className="watch-page__desc-body">{channel.description}</p>
  168. <button
  169. type="button"
  170. className="watch-page__desc-toggle"
  171. onClick={() => setDescOpen((prev) => !prev)}
  172. aria-expanded={descOpen}
  173. >
  174. {descOpen ? '간략히' : '...더보기'}
  175. </button>
  176. </div>
  177. )}
  178. </div>
  179. </div>
  180. {/* 우측 채팅 — YouTube Live Chat iframe (라이브 시) + 후원 버튼 */}
  181. <div className="watch-page__chat">
  182. <YouTubeChatIframe
  183. videoId={isLive ? videoId : null}
  184. onDonate={handleDonate}
  185. />
  186. {/*
  187. * [DEPRECATED] dpot SignalR 채팅 — YouTube iframe 으로 대체.
  188. * quota 승인 후 재활성화 예정.
  189. * <ChatSidebar channelSID={channel.channelSID} onDonate={() => setShowDonation(true)} />
  190. */}
  191. </div>
  192. {/* 후원 알림 토스트 (우상단) — SignalR ReceiveSystemMessage 수신 */}
  193. <DonationToast channelSID={channel.channelSID} />
  194. {/* 후원 모달 */}
  195. {showDonation && (
  196. <DonationModal
  197. channelSID={channel.channelSID}
  198. onClose={() => setShowDonation(false)}
  199. />
  200. )}
  201. </div>
  202. );
  203. }